Part 5|第 21/30 篇
今日要做的事: 在 Alpha 網頁上鎖定 Reality Benchmark 的計時協定與抽樣表,請 Gemini 用兩種問法估六道菜的時間,再讓計時器空跑一次,確認伺服器時間寫得進 Firestore。
今天要解決的目的: A、B1、B2、C 的「可執行」要用同一把尺。Day 21 開火之後,不能再回頭挑對自己有利的算法。
洗菜、切菜、找鍋、等水滾全部不算的話,任何食譜都能寫成「15 分鐘」。
所以今天不煮。先把計時規則鎖起來,明天才准按計時器。

| 項目 | 內容 |
|---|---|
| 產出 | 計時協定、抽樣表、空白 Cook Result Card、六道菜的 Gemini 估時、一次計時器空跑 |
| Google 服務 | Firebase Auth 登入;Firestore 存協定、抽樣表、估時、空跑紀錄;Gemini API Structured Output(gemini-3.5-flash) |
| 沿用 | Day 19 的 Alpha 網頁骨架、canonical 與 SHA-256、Day 7 的 Firestore rules |
| 不做 | 今天先煮一輪、事後回憶時間、先放示範成功數字 |
| 固定廚房 | induction_cooktop、deep_wok、tamagoyaki_pan、microwave;burners=1 |
| 指標 | 採用率、開始率、完成率、時間誤差、hard、剩料、均衡 |
今天的結果只有兩種:協定與抽樣表「已鎖定」,Gemini 的估時「已記錄」。實煮時間、完成率與採用率全部留空,Day 21 才有。
後面要比的四種做法,A、B1、B2、C,都可能寫出 estimated_minutes=15。但如果有人從開火算、有人從洗菜算,這幾個 15 就不能放在同一張成績單上。

最容易被拿掉的是頭尾。食譜寫「下鍋炒 8 分鐘」沒有說謊,可是在那之前要洗番茄、切豆腐、打蛋,熱鍋也要等;炒完還要裝盤。我在廚房花掉的是整段時間,不是中間那段。
所以 Reality Benchmark 的「實際時間」只認一種:
開始動手備料 → 完成可吃
包含洗、切、量、解凍、預熱、等水滾、烹調與必要裝盤。不是從點火開始,也不是吃完才停。
中途接電話這類非料理中斷,時間照樣留在裡面,另記 interruption_seconds 與原因。分析時原始時間和註記一起報,不偷偷刪掉難看的那一筆。
A 一句話直接問
B1 十秒掃冰箱後的粗描述+當下需求
B2 同日完整 pantry JSON,但沒有歷史、Rule、Tools、回寫
C DishFlow 讀 LifeFlow,compile+Tools/snapshot+validate+Outflow
過敏、能不能開鍋、時間上限,這份標準答案四種做法共用,事先藏好,事後不准改。
四種做法看到的資料不一樣,也不把別人的題目轉過去。B1 只有粗略的庫存描述;B2 只有和 C 同一天的冰箱清單;過敏、設備這些硬限制只給 C。B1 看不到數量和保存期限,B2 看不到「今天開不了鍋」。拿不到就是拿不到,評分時照它實際看到的資料算。
{
"timer_definition": "prep_start_to_ready_to_eat",
"started_at": null,
"ready_to_eat_at": null,
"actual_seconds": null,
"interruption_seconds": 0,
"interruption_note": null
}
started_at 和 ready_to_eat_at 都用 Firestore 的 serverTimestamp,actual_seconds 由兩者相減。不用手機時鐘,也不憑印象填一個整數。
採用、開始、完成是三種不同的結果:
adopted=true, started=false 採用但沒動手
started=true, completed=false 動手後沒完成
completed=true 到達「完成可吃」
沒完成的那一列也要留著。只留成功的 run,完成率永遠是 100%。
| id | 菜 | 限制 | 選入理由 |
|---|---|---|---|
| S1 | 番茄豆腐炒蛋 | leftover_first |
半盒豆腐 use_soon,看剩料有沒有被用掉 |
| S2 | 微波洋蔥雞肉蛋蓋飯 | equipment=microwave |
只剩微波爐,A、B1 常寫成要開火 |
| S3 | 歐姆蛋咖哩 | burners=1 |
單口爐不能同時燉咖哩又煎蛋 |
| S4 | 青花菜蝦仁豆腐蒸蛋 | time_cap=15 |
15 分鐘題,看估時準不準 |
| S5 | 蛤蜊番茄蛋湯 | leftover_first |
市場鮮料只放 1~3 天 |
| S6 | 剩湯煮麵 | chain_hit |
用 S5 的剩湯,B 沒有昨天這筆歷史 |
至少完成 6 次,允許 1 次沒做完。這張表先不寫用哪一種做法。Day 21 採用了誰的推薦,再填 A、B1、B2 或 C。
pilot_adoption_rate = adopted / eligible_recommendations_seen
reality_completion_rate = completed_runs / adopted
採用率的分母是「看得到、也能選的推薦」。系統已經回答沒有可行方案的那幾次,不算進分母。一次都沒有可選推薦時,比率寫 null,不寫 0,免得看起來像採用率是零。

Day 19 的網頁骨架原樣搬過來,canonical 和 sha256Hex 直接 import Day 19 的 core.js。
day20/
├─ index.html 五顆按鈕、協定表、抽樣表、估時表、空白卡片
├─ package.json firebase、vite
├─ vite.config.js port 3020,允許讀上一層的 Day 19 模組
├─ .env Firebase 設定與 Gemini 金鑰,不進版控
├─ src/
│ ├─ firebase.js initializeApp、Auth、Firestore
│ ├─ protocol.js PROTOCOL、SAMPLING、lockHash、emptyCookRun
│ ├─ estimate.js 兩種問法、Structured Output、四段加總檢查
│ └─ main.js 鎖定 transaction、估時、計時器空跑
└─ test.js 八個本機測試
Firestore 一樣放在 users/{uid} 底下,Day 7 的 rules 不用改:
users/{uid}/
├─ benchmark/protocol_v1 content、hash、locked_at
├─ benchmark/sampling_v1 content、hash、locked_at
├─ benchmark/cook_run_template_v1 空白 cook_run,帶 protocol_hash
├─ estimates/{S1_naive} Gemini 估時、usage、model
├─ timer_tests/{dry_…} 空跑,dry_run=true
└─ cook_runs/ 今天保持空的
協定不是一段說明文字,而是一個物件。起點、終點、包含什麼、中斷怎麼記,都是欄位:
export const PROTOCOL = {
protocol_id: "reality_benchmark_v1",
timer_definition: "prep_start_to_ready_to_eat",
start_event: "第一個備料動作:拿出食材開始洗、切、量、解凍",
end_event: "成品完成可吃:裝好盤,可以開始吃",
includes: ["洗", "切", "量", "解凍", "預熱", "等水滾", "烹調", "必要裝盤"],
excludes: ["買菜", "飯後洗碗", "吃飯時間"],
interruption: "非料理中斷留在 elapsed time,另記 interruption_seconds 與原因,不刪 run",
// outcomes、timestamps、rates、error ...
};
鎖定用 Firestore transaction。第一次寫入時記下 hash 和 locked_at;之後同一份內容可以重按,內容不同就拒絕:
const outcome = await runTransaction(db, async (tx) => {
const snap = await tx.get(ref);
if (!snap.exists()) {
tx.set(ref, { content: value, hash, locked_at: serverTimestamp() });
return "locked";
}
if (snap.data().hash !== hash) {
throw new Error(`${name} 已鎖定,內容不同,要改請開新版本`);
}
return "unchanged";
});
把起點從「第一個備料動作」改成「點火」,hash 就變了,Firestore 上的 v1 不會被覆蓋。要改只能開 protocol_v2,而 Day 21 之後每一筆 cook_run 都會帶著自己用的 protocol_hash,混不進去。
今天只有這裡會呼叫 Gemini。同一道菜、同一個廚房,只差有沒有告訴它從哪一秒開始算:
const prompts = {
naive: (row) => `${row.dish}要幾分鐘?${kitchenLine(row)}
estimated_minutes 填一個整數。counted_from、counted_to 照你這個數字實際的起點和終點選。`,
protocol: (row) => `${row.dish}要幾分鐘?${kitchenLine(row)}
計時定義:${PROTOCOL.timer_definition}
起點:${PROTOCOL.start_event}
終點:${PROTOCOL.end_event}
包含:${PROTOCOL.includes.join("、")}
不含:${PROTOCOL.excludes.join("、")}
把時間拆成 prep、passive_wait、cook、plating 四段,estimated_minutes 是四段加總。`,
};
沒給定義的那一版,輸出格式限定它只能從這幾個起點和終點裡選,不能另寫一句帶過:
counted_from: { type: "STRING", enum: ["開始備料", "點火", "食材下鍋", "沒有說明"] },
counted_to: { type: "STRING", enum: ["完成可吃", "熄火", "沒有說明"] },
給定義的那一版要拆四段。四段加起來不等於總數,這個估時就不能拿來算誤差:
export function checkBreakdown(result) {
const sum = result.prep_minutes + result.passive_wait_minutes + result.cook_minutes + result.plating_minutes;
return { sum, consistent: sum === result.estimated_minutes };
}
兩種估時都寫進 estimates。給定義的那一筆帶 protocol_hash,Day 21 算誤差時,estimated_minutes 就從這裡拿。
await setDoc(timerRef, { dry_run: true, protocol_hash: protocolHash, started_at: serverTimestamp() });
// ……停止
await updateDoc(timerRef, { ready_to_eat_at: serverTimestamp() });
const data = (await getDoc(timerRef)).data();
const actual = Math.round((data.ready_to_eat_at.toMillis() - data.started_at.toMillis()) / 1000);
開始和停止都是 Firestore 打的時間戳,手機時鐘快兩分鐘也不影響。今天這次寫進 timer_tests,標 dry_run: true,不進 cook_runs,也不算成績。
export function emptyCookRun(scenarioId, protocolHash) {
return {
run_id: null, scenario_id: scenarioId, protocol_hash: protocolHash,
method: null, request_id: null, food_source: "home",
adopted: null, started: null, completed: null,
estimated_minutes: null,
timer: { started_at: null, ready_to_eat_at: null, actual_seconds: null, interruption_seconds: 0, interruption_note: null },
scores: { hard_ok: null, time_ok: null, pantry_reuse_ok: null, balance_ok: null, deliciousness_1_to_5: null },
pantry_delta: [], tip_card: null,
};
}
模板存在 benchmark/cook_run_template_v1,cook_runs 集合今天一筆都沒有。自煮要到 completed=true 才建立 cook_run,接著跑 pantry Outflow、產生 tip_card;按採用或按開始都不扣冰箱。外食不進實煮計時,只寫 today_meals,pantry_delta 必須是空的。
現場只做四件事,其他欄位餐後再依 request snapshot 補:
開始動手備料時按開始;完成可吃時按停止
標記完成或中止;補一句現場異常

八個測試鎖住這幾件事:同一份協定 hash 不變;起點改成「點火」、抽樣表少一列,hash 都會換;空白 cook_run 的結果欄全是 null;採用、開始、完成分得開;分母是 0 時比率是 null;誤差用秒換算;四段加總不等於總數會被抓出來。
登入後按 1、2。左下角的「Firestore 上的鎖定」是從 Firestore 讀回來的,不是頁面自己記的:

協定 0a3191790a9bb084 2026/10/5 23:11:45
抽樣表 bb9d5a895d9fe7cd 2026/10/5 23:11:53
再按一次「鎖定計時協定」,因為內容沒變,只會回「之前就鎖了,內容相同」,locked_at 不會被改。
按 3,同一個 gemini-3.5-flash 對六道菜各問兩次,一共 12 次呼叫:

| id | 菜 | 沒給定義 | 它說從哪算到哪 | 給定義 | 備料/等待/烹調/裝盤 | 差 |
|---|---|---|---|---|---|---|
| S1 | 番茄豆腐炒蛋 | 15 | 開始備料 → 完成可吃 | 15 | 5/0/9/1 | 0 |
| S2 | 微波洋蔥雞肉蛋蓋飯 | 5 | 食材下鍋 → 完成可吃 | 14 | 7/0/5/2 | +9 |
| S3 | 歐姆蛋咖哩 | 35 | 開始備料 → 完成可吃 | 48 | 12/15/18/3 | +13 |
| S4 | 青花菜蝦仁豆腐蒸蛋 | 10 | 點火 → 熄火 | 14 | 5/6/2/1 | +4 |
| S5 | 蛤蜊番茄蛋湯 | 15 | 開始備料 → 完成可吃 | 20 | 6/4/8/2 | +5 |
| S6 | 剩湯煮麵 | 8 | 點火 → 熄火 | 12 | 3/4/4/1 | +4 |
六道加起來,沒給定義是 88 分鐘,給了定義是 123 分鐘,多出 35 分鐘。四段加總六道都對得上。
我原本以為 Gemini 會亂報數字,結果起訖它都照實填了:S2 填「食材下鍋」開始,S4、S6 填「點火 → 熄火」。問題不在它少報,而是我沒說要從哪一秒算。這三道給了定義之後,各多了 4 到 9 分鐘,多出來的幾乎都是備料。
比較麻煩的是另外三道。S3、S5 沒給定義時就說自己是「開始備料 → 完成可吃」,給了定義後還是多了 13 分鐘和 5 分鐘。可見模型說「我有算備料」,不等於它真的把備料算足了。只有 S1 兩次都是 15。
S4 也要記一筆。這題的限制是 time_cap=15,給了定義後它報 14 分鐘,剛好壓在上限下面。這可能是真的,也可能是被題目的 15 牽著走。Day 21 煮完,伺服器時間會告訴我們是哪一種。
這張表是預測,不是成績。給定義的那六筆已經帶著 protocol_hash 寫進 estimates,Day 21 的 estimated_minutes 從這裡拿。
按 4,等了一下再按 5:

started_at 2026/10/5 23:16:06
ready_to_eat_at 2026/10/5 23:16:29
actual_seconds = 23
23 秒是兩個 Firestore 伺服器時間相減出來的。這筆寫在 timer_tests,標 dry_run: true,不進 cook_runs,也不算成績。這次只是確認明天按下開始時,時間記得進去。
到 Firebase console 打開 benchmark/protocol_v1:

存進去的是整份協定物件,不是一段說明文字:start_event、timer_definition、outcomes 三種結果、rates 的分母規則都在。最下面是 hash: "0a3191790a9bb084" 和 locked_at: 2026年10月5日 晚上11:11:45,跟控制台左下角對得上。同一層還有 sampling_v1 和 cook_run_template_v1,cook_runs 集合還沒出現。
前三種會讓 Reality Benchmark 變回美食心得;第四種今天已經用 hash 擋住。
Day 0 的 Cost 是錢、時間和心力。前面幾篇管的是錢和 token;今天管的是時間,而且是使用者在廚房真的付出的那段時間。
模型說 15 分鐘,使用者花了 25 分鐘,差的那 10 分鐘就是 Cost 被低估的地方。今天的估時表已經先看到一次:同一個 Gemini,沒講清楚從哪算起,六道菜就少報 35 分鐘。起點如果可以隨便挪,這個誤差永遠可以被算成 0,Cost 這一側就量不準。
所以今天先校準這把尺。Day 21 開始,每一筆實煮都用同一把:Gemini 的估時在 estimates,伺服器量到的時間在 cook_runs,兩邊都帶著同一個 protocol_hash。
cook_runs 是空的,23 秒的空跑只驗證計時器寫得進去。下一篇: 正式開火,記下第一批從備料開始算的真實 cook_runs。
error_minutes = actual_seconds / 60 - estimated_minutes
absolute_error_minutes = abs(error_minutes)
MAE 要等有真實的完成紀錄才算。中止的那幾次另外報完成率,不硬塞一個虛構的完成時間。
煮完給人看的只有幾格:過不過過敏和設備、預算、時間、有沒有用到剩料、均不均衡、好不好吃,再加一句當天的 tip。開始和結束的時間戳、用的是哪一種做法、引用了哪些資料、中途為什麼中斷,都留在評測資料裡,不塞進這張卡片。